Next.js 16 + Cloudflare Workers で動くパーソナル RSS リーダー。rss.0g0.xyz でホスト中。
| 機能 | 説明 |
|---|---|
| 📰 5種類のレイアウト | コンパクト / リスト / カード / マガジン / ギャラリー |
| ✅ 既読・ブックマーク・後で読む | 記事ごとに状態を管理。デバイス間で自動同期 |
| 🔍 全文取得 | RSS サマリーだけでなく記事本文をその場で取得 |
| 🤖 AI 要約・翻訳 | Workers AI (Llama) による記事要約と日本語翻訳 |
| 🌙 ダーク / ライトテーマ | システム設定に追従、手動切り替えも可能 |
| 🔎 全文検索 | フィールド指定・正規表現対応のクライアントサイド検索 |
| 🏷️ タグ・メモ・スヌーズ | 記事へのタグ付け、個人メモ、指定日時まで非表示 |
| 📤 OPML インポート / エクスポート | 他リーダーからの移行・バックアップに対応 |
| ⌨️ キーボードナビゲーション | j/k で移動、o で開く、b でブックマーク、? でヘルプ |
| 🔔 Web Push 通知 | 新着記事をプッシュ通知で受け取る |
rss.0g0.xyz を開き「Google でログイン」をクリック。 0g0 ID (OAuth2) 経由で認証が完了するとメイン画面に遷移します。
左サイドバー上部の 「+ フィードを追加」 ボタンをクリックし、RSS フィードの URL またはサイトの URL を入力します。
- RSS 自動検出: サイトの URL を入力すると、
<link rel="alternate">タグや一般的なパスから RSS フィードを自動検出します。 - LLM フォールバック: RSS フィードが見つからない場合は Workers AI がサイト構造を解析し、CSS セレクタを推論して記事リストを取得します。
- OPML インポート: 他のリーダーからまとめて移行する場合は「インポート」からOPMLファイルをアップロードしてください。
画面は 3ペインレイアウト で構成されています。
┌─────────────┬──────────────────┬──────────────────────────┐
│ サイドバー │ 記事一覧 │ 記事本文 │
│ (フィード) │ (タイトル一覧) │ (フルテキスト表示) │
└─────────────┴──────────────────┴──────────────────────────┘
- サイドバー: 購読フィード一覧、グループ、ブックマーク、履歴、コレクション等の特殊ビュー
- 記事一覧: 選択中フィードの記事。未読のみ表示・レイアウト切り替え可
- 記事本文: 選択した記事の本文。「全文取得」ボタンで元サイトから本文を取得できる
| キー | 操作 |
|---|---|
j / k |
次の記事 / 前の記事 |
n / p |
次のフィード / 前のフィード |
o |
記事を元サイトで開く |
b |
ブックマーク切り替え |
r |
既読 / 未読切り替え |
m |
一括既読(現在のフィード) |
u |
未読フィルター切り替え |
s |
スヌーズ |
f |
全文取得 |
a |
AI 要約 |
z |
AI 翻訳 |
? |
ショートカット一覧を表示 |
全ショートカットの詳細は docs/keyboard-shortcuts.md を参照してください。
| レイヤー | 技術 |
|---|---|
| フレームワーク | Next.js 16 App Router + @opennextjs/cloudflare |
| フロントエンド | React 19 + TypeScript + Tailwind CSS v4 |
| API | Next.js Route Handlers (app/api/**) |
| 認証 | 0g0 ID (OAuth2 + ES256 JWT) |
| データ | Cloudflare R2 (rss-reader-data) + KV (RATE_LIMIT) — ユーザー別 JSON + レートリミット |
| AI | Workers AI (要約・翻訳・フィード推薦) |
| 自動更新 | Cloudflare Cron Trigger(30分ごと) |
| デプロイ | Cloudflare Workers CI/CD(master push で自動ビルド&デプロイ) |
主要な操作はすべてキーボードから実行できる。詳細は docs/keyboard-shortcuts.md を参照。
アプリ内では ? キーで一覧モーダルを表示できる。
pnpm installnpx wrangler r2 bucket create rss-reader-datahttps://id.0g0.xyz にログインし、OAuth2 アプリを登録する。登録時の必須項目:
| 項目 | 値 |
|---|---|
| アプリ名 | 任意(例: RSS Reader) |
| Callback URL | https://your-domain.com/api/auth/callback |
| 許可スコープ | openid profile email |
Callback URL は必ず
APP_BASE_URL + /api/auth/callbackの形で登録する。ローカル開発用には別途http://localhost:3000/api/auth/callbackを追加する。
登録完了後、発行される CLIENT_ID / CLIENT_SECRET を次節で設定する。
Web Push 通知の VAPID 鍵ペアを生成する。Node.js 18.17 以上 が必要:
node scripts/generate-vapid-keys.mjs出力例:
=== VAPID 鍵ペア ===
VAPID_PUBLIC_KEY (65 bytes uncompressed P-256):
BN1q...(base64url、88 文字程度)
VAPID_PRIVATE_KEY (32 bytes P-256 scalar):
xY7k...(base64url、43 文字程度)
この 2 つの値を次節でシークレットとして登録する。
全文取得フォールバック (toMarkdown API) を使う場合のみ必要。使わなければスキップ可。
- Cloudflare ダッシュボード → My Profile → API Tokens を開く
- 「Create Token」→「Create Custom Token」を選択
- 権限 (Permissions) に次を追加:
Account/Workers AI/Read
- アカウントリソースを該当アカウントに限定して作成
- 同じダッシュボード右サイドバーの「Account ID」を控える
toMarkdown用のCLOUDFLARE_ACCOUNT_IDはwrangler.tomlの[vars]に平文で持つか、npx wrangler secret put CLOUDFLARE_ACCOUNT_IDで設定する。
0g0 ID で登録した CLIENT_ID / CLIENT_SECRET を設定:
npx wrangler secret put CLIENT_ID
npx wrangler secret put CLIENT_SECRET手順 4 で生成した VAPID 鍵:
npx wrangler secret put VAPID_PUBLIC_KEY
npx wrangler secret put VAPID_PRIVATE_KEY手順 5 の Cloudflare API トークン(toMarkdown フォールバック用、オプション):
npx wrangler secret put CLOUDFLARE_API_TOKEN
npx wrangler secret put CLOUDFLARE_ACCOUNT_ID # wrangler.toml [vars] に記載しない場合Brave Search API キー(フィード推薦で外部検索を使う場合のみ、オプション):
npx wrangler secret put BRAVE_SEARCH_API_KEYセルフホスト RSSHub を使う場合(オプション):
npx wrangler secret put RSSHUB_INSTANCE_URL # 例: https://rsshub.example.com
npx wrangler secret put RSSHUB_ACCESS_KEY # RSSHub のアクセスキー(未設定時はなし)wrangler.toml の [vars] を環境に合わせて更新:
[vars]
AUTH_BASE_URL = "https://id.0g0.xyz" # 0g0 ID エンドポイント
APP_BASE_URL = "https://your-domain.com" # アプリのドメイン(Callback URL のプレフィックス)
VAPID_SUBJECT = "mailto:admin@example.com" # Web Push 送信元メール
BETA_ALLOWED_SUBS = "" # ベータ制限: カンマ区切り sub リスト。空文字で制限なしpnpm run dev # Next.js dev server (localhost:3000)
pnpm run preview # Cloudflare Workers ローカルエミュレーション (wrangler dev)pnpm run dev # Next.js dev server (localhost:3000)
pnpm run build # next build(動作確認・型チェック込み)
pnpm run preview # Workers ローカルエミュレーション (wrangler dev)
pnpm run build:cf # Cloudflare Workers 向けビルド(CI/CD が自動実行するため手動不要)
pnpm run deploy # ローカルから手動デプロイ(通常不要)
pnpm run check # Oxlint + Oxfmt + tsgo 型チェック
pnpm run check:fix # 自動修正付きチェック
pnpm run typecheck # tsc --noEmit(完全な型チェック)
pnpm run test:e2e # Playwright E2E テスト実行
pnpm run test:e2e:ui # Playwright UI モード(デバッグ用)デプロイについて:
masterブランチへの push で Cloudflare Workers 側が自動ビルド&デプロイを実行する。ローカルでdeployを手動実行する必要はない。
.pre-commit-config.yaml で以下のフックがコミット時に自動実行される:
- oxlint + oxfmt — lint & フォーマット自動修正
- tsc --noEmit — 型チェック
- playwright e2e — E2E テスト
pre-commit install # 初回セットアップ| メソッド | パス | 説明 |
|---|---|---|
| GET | /api/auth/login |
OAuth2 認証開始 |
| GET | /api/auth/callback |
OAuth2 コールバック |
| GET | /api/auth/me |
セッション確認・自動リフレッシュ |
| POST | /api/auth/logout |
ログアウト(cookie クリア) |
| POST | /api/auth/dbsc/register |
DBSC 公開鍵登録(スタブ) |
| POST | /api/auth/dbsc/challenge |
DBSC チャレンジ発行・検証(スタブ) |
| DELETE | /api/auth/dbsc/session |
DBSC バインド済みデバイス登録解除 |
| メソッド | パス | 説明 |
|---|---|---|
| GET | /api/feeds |
フィード一覧取得 |
| POST | /api/feeds |
フィード追加 { url } |
| DELETE | /api/feeds/:id |
フィード削除 |
| PATCH | /api/feeds/:id |
フィード設定更新 |
| POST | /api/feeds/:id/refresh |
単体フィード手動更新 |
| POST | /api/feeds/:id/reinfer |
LLM CSS セレクタ再推論 |
| POST | /api/feeds/refresh |
全フィード手動更新 |
| POST | /api/feeds/import |
OPML インポート |
| GET | /api/feeds/export |
OPML エクスポート |
| メソッド | パス | 説明 |
|---|---|---|
| GET | /api/feed-groups |
グループ一覧取得(order 昇順ソート) |
| POST | /api/feed-groups |
グループ新規作成 { name } → 201 Created で FeedGroup を返す |
| PATCH | /api/feed-groups/:id |
グループ更新 { name?, order?, collapsed?, muted? }(部分更新) |
| DELETE | /api/feed-groups/:id |
グループ削除(所属購読の groupId は自動クリアを試みる) |
- レスポンス型は
FeedGroup = { id, name, order, collapsed?, muted?, createdAt } - POST 時の
orderは既存グループの最大値 + 1 で自動採番される - グループ上限は 100 件 (
MAX_FEED_GROUPS_PER_USER)、名前は最大 50 文字 (FEED_GROUP_NAME_MAX_LENGTH) でユーザー内重複不可 - 保存先:
users/{userId}/feed-groups.json(JSON 配列) - DELETE は R2 のトランザクション非対応のため、グループ除去後に購読側の
groupIdクリアを行う。後半が失敗すると orphan なgroupIdが購読側に残るが、クライアントは未知のgroupIdを無視するため実害はない
| メソッド | パス | 説明 |
|---|---|---|
| GET | /api/collections |
コレクション一覧取得 |
| POST | /api/collections |
コレクション作成 { name } → 201 Created |
| PATCH | /api/collections/:id |
コレクション更新 { name?, order?, addArticleId?, removeArticleId? } |
| DELETE | /api/collections/:id |
コレクション削除 |
| メソッド | パス | 説明 |
|---|---|---|
| GET | /api/articles |
記事一覧取得 |
| POST | /api/articles/save |
記事保存 |
| GET | /api/content?url=... |
記事フルテキスト取得プロキシ |
| GET | /api/ogp?url=... |
OGP 画像 URL 取得 |
| GET | /api/image-proxy?url=... |
外部画像プロキシ |
| POST | /api/clip |
SingleFile 拡張からの HTML クリップ保存 |
| メソッド | パス | 説明 |
|---|---|---|
| GET | /api/read-state |
既読・ブックマーク・後で読む・スヌーズ状態取得 |
| POST | /api/read-state |
状態を R2 に保存(2秒デバウンス後) |
| メソッド | パス | 説明 |
|---|---|---|
| POST | /api/ai/summarize |
記事要約 (Workers AI) |
| POST | /api/ai/translate |
記事翻訳 (Workers AI) |
| メソッド | パス | 説明 |
|---|---|---|
| GET | /api/recommendations |
推薦フィード一覧 |
| POST | /api/recommendations/dismiss |
推薦を非表示 |
| POST | /api/recommendations/refresh |
推薦を再生成 |
| メソッド | パス | 説明 |
|---|---|---|
| GET | /api/push/vapid-key |
VAPID 公開鍵取得 |
| GET | /api/push/status |
サブスクリプション状態確認 |
| POST | /api/push/subscribe |
Push 通知登録 |
| POST | /api/push/unsubscribe |
Push 通知解除 |
| POST | /api/push/test |
テスト通知送信 |
| メソッド | パス | 説明 |
|---|---|---|
| GET | /api/stats |
読了統計(日別・ヒートマップ等) |
| GET | /api/engagement |
エンゲージメント記録取得 |
| POST | /api/engagement |
エンゲージメント記録 |
| GET | /api/release-notes |
リリースノート |
| GET | /api/health |
ヘルスチェック |
すべてのエラーは src/lib/api-error.ts の apiError() ヘルパーによって以下の統一形式で返される。
{
"error": "人間可読メッセージ",
"code": "MACHINE_READABLE_CODE",
"hint": "ユーザー向け補足(オプション)",
"retryable": true,
"retryAfter": 30
}| フィールド | 型 | 説明 |
|---|---|---|
error |
string |
人間可読のエラーメッセージ |
code |
string? |
クライアントが分岐に使う機械可読コード(SCREAMING_SNAKE_CASE) |
hint |
string? |
ユーザー向けの補足ヒント |
retryable |
boolean? |
リトライで成功する可能性がある場合 true |
retryAfter |
number? |
リトライまでの秒数(429 時は Retry-After ヘッダーにも同値が入る) |
| ステータス | code |
発生条件 |
|---|---|---|
| 400 | INVALID_JSON |
リクエストボディが JSON としてパース失敗 |
| 401 | UNAUTHORIZED |
セッション未認証またはトークン失効(withSession 経由) |
| 429 | RATE_LIMITED |
クールダウン中(Retry-After ヘッダー付与) |
| 500 | INTERNAL_ERROR |
想定外サーバーエラー(withSession の例外ハンドラ) |
| エンドポイント | ステータス | code |
説明 |
|---|---|---|---|
POST /api/feeds |
400 | INVALID_URL |
URL が空または http/https でない |
POST /api/feeds |
400 | INVALID_COOKIE |
cookie 値が不正 |
POST /api/feeds |
400 | INVALID_SELECTOR |
cssSelector が 1〜500 文字外、または構文不正 |
POST /api/feeds |
409 | FEED_EXISTS |
同じ feedHash がすでに購読済み |
POST /api/feeds |
422 | FEED_NOT_FOUND |
RSS 探索・LLM 推論ともに失敗(canRetryWithSelector: true 付き) |
POST /api/feeds |
422 | FEED_LIMIT_REACHED |
1 ユーザー当たりの上限超過 |
PATCH /api/feeds/:id |
400 | INVALID_TITLE ほか |
title / filter / nsfw / priority / category / mutedUntil いずれかが不正 |
PATCH/DELETE /api/feeds/:id |
404 | FEED_NOT_FOUND |
該当購読またはメタが存在しない |
POST /api/feeds/:id/refresh |
404 | FEED_NOT_FOUND |
購読が存在しない |
POST /api/feeds/:id/reinfer |
400 | NOT_LLM_FEED |
LLM スクレイピングではないフィードに対する再推論 |
POST /api/feeds/:id/reinfer |
422 | REINFER_FAILED |
LLM が新しいセレクタを生成できなかった |
POST /api/feeds/import |
400 | INVALID_OPML |
OPML が空・1MB 超・パース失敗 |
POST /api/feeds/import |
400 | EMPTY_OPML |
OPML から 1 件もフィードを抽出できなかった |
POST /api/feeds/import |
400 | OPML_TOO_MANY_FEEDS |
1 回のインポートあたりの上限超過 |
POST /api/feeds/import |
422 | FEED_LIMIT_REACHED |
ユーザーの購読上限に達している |
| エンドポイント | ステータス | code |
説明 |
|---|---|---|---|
POST /api/feed-groups |
400 | INVALID_NAME |
name が空・文字列でない・50 文字超 |
POST /api/feed-groups |
409 | DUPLICATE_NAME |
同名グループがすでに存在 |
POST /api/feed-groups |
409 | FEED_GROUP_LIMIT_EXCEEDED |
グループ数上限(100)に到達 |
PATCH /api/feed-groups/:id |
400 | INVALID_NAME |
name が空・文字列でない・50 文字超 |
PATCH /api/feed-groups/:id |
400 | INVALID_ORDER |
order が整数でない |
PATCH /api/feed-groups/:id |
400 | INVALID_COLLAPSED |
collapsed が boolean でない |
PATCH /api/feed-groups/:id |
400 | INVALID_MUTED |
muted が boolean でない |
PATCH /api/feed-groups/:id |
409 | DUPLICATE_NAME |
別グループが同名 |
PATCH/DELETE /api/feed-groups/:id |
404 | FEED_GROUP_NOT_FOUND |
該当グループが存在しない |
| エンドポイント | ステータス | code |
説明 |
|---|---|---|---|
GET /api/articles |
400 | INVALID_FEED / INVALID_PAGE |
feed/page クエリが不正 |
GET /api/articles |
404 | FEED_NOT_FOUND |
指定された feed が購読リストに存在しない |
POST /api/articles/save |
400 | INVALID_URL |
url が空または http/https でない |
POST /api/articles/save |
422 | SAVED_LIMIT_REACHED |
保存記事の上限に達した |
GET /api/content |
400 | INVALID_URL |
url クエリが空または http/https でない |
GET /api/content |
4xx | FETCH_FAILED |
取得先が 4xx を返した(元ステータスをそのまま返す) |
GET /api/content |
413 | PAYLOAD_TOO_LARGE |
取得先のサイズが上限超過 |
GET /api/content |
415 | UNSUPPORTED_CONTENT_TYPE |
HTML 以外(text/html を含まない Content-Type) |
GET /api/content |
502 | EMPTY_BODY / FETCH_FAILED |
レスポンスボディなし、またはネットワーク失敗(retryable: true) |
GET /api/content |
504 | TIMEOUT |
フェッチタイムアウト(retryable: true) |
POST /api/clip |
400 | INVALID_CLIP_PAYLOAD |
SingleFile 拡張からのペイロードが不正 |
| エンドポイント | ステータス | code |
説明 |
|---|---|---|---|
POST /api/read-state |
413 | PAYLOAD_TOO_LARGE |
同期ペイロードが上限超過 |
| エンドポイント | ステータス | code |
説明 |
|---|---|---|---|
POST /api/ai/{summarize,translate} |
400 | INVALID_URL |
url が空または http/https でない |
POST /api/ai/{summarize,translate} |
401 | UNAUTHORIZED |
Workers AI が 401 を返した |
POST /api/ai/{summarize,translate} |
429 | RATE_LIMITED |
ユーザークールダウン中、または Workers AI が 429 返却 |
POST /api/ai/{summarize,translate} |
502 | CONTENT_FETCH_FAILED |
元記事の取得失敗(retryable: true) |
POST /api/ai/{summarize,translate} |
502 | AI_ERROR |
Workers AI 呼び出しが想定外失敗(retryable: true) |
POST /api/ai/{summarize,translate} |
503 | SERVICE_UNAVAILABLE |
Workers AI が 503 を返した(retryable: true) |
| エンドポイント | ステータス | code |
説明 |
|---|---|---|---|
POST /api/recommendations/dismiss |
400 | INVALID_ID |
id クエリが空または不正 |
| エンドポイント | ステータス | code |
説明 |
|---|---|---|---|
POST /api/push/subscribe |
400 | INVALID_SUBSCRIPTION |
サブスクリプションオブジェクトが不正 |
POST /api/push/subscribe |
400 | INVALID_ENDPOINT |
endpoint URL が不正 |
POST /api/push/subscribe |
400 | INVALID_P256DH |
p256dh 公開鍵が不正 |
POST /api/push/subscribe |
400 | INVALID_AUTH_KEY |
auth 認証鍵が不正 |
POST /api/push/subscribe |
429 | TOO_MANY_SUBSCRIPTIONS |
1 ユーザー当たりの登録上限超過 |
POST /api/push/unsubscribe |
400 | INVALID_ENDPOINT |
endpoint URL が空または不正 |
GET /api/push/vapid-key |
503 | PUSH_NOT_CONFIGURED |
サーバー側 VAPID 公開鍵が未設定 |
POST /api/push/test |
503 | VAPID_NOT_CONFIGURED |
サーバー側 VAPID 鍵が未設定(hint に設定コマンド) |
POST /api/push/test |
404 | NO_SUBSCRIPTIONS |
このユーザーに登録済みサブスクリプションがない |
| エンドポイント | ステータス | code |
説明 |
|---|---|---|---|
POST /api/engagement |
400 | INVALID_PAYLOAD |
payload の形式・値が不正 |
新しいエラーコードを追加する場合は
src/lib/api-error.tsのapiError()を経由し、上記表に追記すること。
# 共有フィードデータ(ユーザー間で共有)
feeds/{feedHash}/meta.json # SharedFeedMeta(フィードメタ情報)
feeds/{feedHash}/articles/latest.json # Article[](最新 500 件)
feeds/{feedHash}/articles/p{N}.json # Article[](古いページ、N >= 2)
# ユーザー別データ
users/{userId}/subscriptions.json # UserSubscription[](購読フィード一覧)
users/{userId}/profile.json # UserProfile(ログイン時に保存)
users/{userId}/read-state.json # ReadState(既読・ブックマーク・いいね・メモ等)
users/{userId}/engagement.json # EngagementLog(行動履歴)
users/{userId}/recommendations.json # RecommendationCache(フィード推薦キャッシュ)
users/{userId}/push.json # PushConfig(Web Push サブスクリプション)
users/{userId}/feed-groups.json # FeedGroup[](フィードグループ定義)
users/{userId}/collections.json # Collection[](コレクション定義)
users/{userId}/saved.json # 手動保存記事(/api/articles/save)
# サーバーサイドセッション
sessions/{sessionId}.json # ServerSessionData(refreshToken 管理)
# AI キャッシュ(永続)
ai-cache/summary/{sha256} # AI 要約キャッシュ
ai-cache/translation/{sha256} # AI 翻訳キャッシュ
userId = JWT の sub クレームをそのまま使用。
feedHash = sha256(feedUrl).slice(0, 16)(URL からの決定論的な識別子)。
記事データはユーザー別ではなくフィード単位で共有管理されるため、複数ユーザーが同じフィードを購読しても記事フェッチは 1 回だけ実行される。
運用向け: バックアップ・ディザスタリカバリ手順は docs/backup-recovery.md を参照。
クライアント優先・サーバー同期の二重管理方式:
localStorageに既読・ブックマーク・後で読む ID を保持(オフライン対応)- ログイン時に
/api/read-stateでサーバーデータとマージ(ローカル ∪ サーバー) - 状態変更から 2秒後にデバウンスして R2 に同期
- ページ離脱時 (
beforeunload) はsendBeaconで即時送信
package.json の pnpm.overrides は脆弱性対応のためにサブ依存のバージョンを強制固定している。
各エントリの根拠と、削除可能になる条件は以下の通り:
| パッケージ | 強制バージョン | 対応 CVE / 理由 |
|---|---|---|
path-to-regexp |
^6.3.0 |
CVE-2024-45296 — ReDoS 脆弱性。6.2.x 以下で壊滅的バックトラッキングが発生。 |
yaml |
>=2.8.3 |
CVE-2025-27789 — Prototype Pollution / DoS。2.8.2 以下で発生、2.8.3 で修正。 |
brace-expansion |
>=5.0.5 |
ReDoS 脆弱性対策。特定パターンの展開で壊滅的バックトラッキングが発生する。 |
minimatch |
>=10.0.0 |
brace-expansion 依存の ReDoS 脆弱性に連鎖するため、対応版に固定。 |
vite |
>=8.0.5 |
パストラバーサル / SSRF 系脆弱性対策(詳細は vite の該当リリースノートを参照)。 |
postcss |
>=8.5.13 |
CVE-2025-6245 ほか — コードインジェクション脆弱性対策。 |
fast-xml-parser |
>=5.7.0 |
Prototype Pollution / Entity Expansion DoS 対策。5.7.0 で修正。 |
削除タイミング: 直接依存(Next.js / vite 等)が対応版に更新されたら該当
overrideを削除できる。 削除前にpnpm why <pkg>でバージョンが引き上げ済みであることを確認すること。
このプロジェクトは MIT License の下で公開されています。
| パッケージ | ライセンス |
|---|---|
| Next.js | MIT |
| React | MIT |
| Tailwind CSS | MIT |
| @opennextjs/cloudflare | MIT |
| @mozilla/readability | Apache-2.0 |
| fast-xml-parser | MIT |
| linkedom | ISC |
| highlight.js | BSD-3-Clause |
| katex | MIT |
| marked | MIT |
| masonic | MIT |
| @tanstack/react-virtual | MIT |
記事詳細ビューの設計・UXは Readeck (AGPL v3.0) を参考にしています。 コードの直接流用はなく、設計・機能アイデアのみを参考にしています。